Como crear un API con AWS CDK y Typescript

Si ya sabes como crear tu API REST en API Gateway, probablemente te hayas dado cuenta que hacer las actualizaciones es muy simple siempre y cuando solo tengas pocas rutas en el mismo.
Sin embargo mientras el API se va haciendo más extenso, la complejidad también irá aumentando y por ende te costará mas trabajo hacer cambios al mismo.

La forma más ágil de solucionar esto es a través de código y es ahí donde utilizamos AWS CDK (Cloud Development Kit).


Si nunca lo has usado puedes revisar como instalarlo y empezar a usarlo con una Lambda aquí.

Una vez sepas como hacer un despliegue simple con CDK puedes continuar con este post.


Para este ejemplo estoy utilizando 2 archivos:

  1. cdk.ts
  2. rest-api-lambda-integration-stack.ts

Si solo te interesa el código, puedes saltarte al final del post, donde encontrarás ambos archivos completos.
Si te interesa la explicación entender que hace cada sección, sigue leyendo

Inicialización

cdk.ts

Este archivo es el root de nuestro proyecto. Aquí es donde se inicializa la aplicación y el stack que vamos a desplegar.

#!/usr/bin/env node
import 'source-map-support/register';
import * as cdk from '@aws-cdk/core';

import { RestApiLambdaIntegrationStack } from '../lib/rest-api-lambda-integration-stack';

const app = new cdk.App();

new RestApiLambdaIntegrationStack(app, 'RestApiLambdaIntegrationStack', {})

Primero se necesita definir un objeto App que básicamente define nuestra aplicación de CDK.

Lo siguiente es mandar a llamar la clase de tipo Stack que hayas creado. En este caso la clase que voy a usar se llama RestApiLambdaIntegrationStack.

En el constructor se necesitan 3 propiedades:

  • Una instancia de la app
  • El id del stack
  • Las propiedades de configuración

En las propiedades puedes especificar configuración relacionada a la cuenta o a la región, por si quieres que todo el stack solo funcione en cierta configuración. En este caso quiero que el código funcione para cualquier escenario así que lo dejo vacío.

Implementación del stack

rest-api-lambda-integration-stack.ts

En este archivo llamó a 3 funciones dentro del constructor:

  • instantiateLambdaFunction
  • instantiateRestAPI
  • configureDeployments
export class RestApiLambdaIntegrationStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const lambdaFunction = this.instantiateLambdaFunction()
    const restAPI = this.instantiateRestAPI(lambdaFunction)

    this.configureDeployments(restAPI)
    
  }
}

Veamos cada una:

Creación de la función Lambda

instantiateLambdaFunction()

Este función se encarga de crear la función Lambda que el API va a llamar.

private instantiateLambdaFunction = () => {
    const lambdaFunctionId = 'dataLambda'
    const functionLocationFolderName = 'dataLambda'
    const functionHandlerFolderName = 'dataLambda'
    const functionHandlerFunctionName = 'dataLambda'

    const lambdaFunction = new Function(this, lambdaFunctionId, {
      functionName: functionHandlerFunctionName,
      runtime: Runtime.NODEJS_14_X,
      code: Code.fromAsset(`src/${functionLocationFolderName}`),
      
      handler: `${functionHandlerFolderName}.${functionHandlerFunctionName}`,
    })

    return lambdaFunction
}

Dentro del constructor de la función Lambda se pasan 3 parámetros:

  • this – Hace referencia al stack que esta llamando a este constructor
  • lambdaFunctionId – El id de la función. Único por cada función que vayas a crear.
  • objeto – Un objeto de tipo FunctionProps.

Un objeto de FunctionProps tiene muchas propiedades que sirven para configurar la Lambda. Sin embargo en este ejemplo solo estoy utilizando 4:

<strong>functionName</strong> – El nombre de la función Lambda. Este es el nombre que va a aparecer en la consola de administración de AWS.

runtime – El lenguaje de programación que esta utilizando tu función. En mi caso, Nodejs

code – El código que vas a cargar para la función lambda. CDK te permite especificar el código haciendo referencia a un archivo que tengas guardado en S3 o si lo prefieres a un archivo que tengas en tu equipo. En mi caso yo estoy haciendo referencia a código que tengo dentro de mi proyecto en la carpeta src.

handler – La ruta del archivo y la función que llamará la función al inicializarse. El formato es: <archivo>.<función>

Otra cosa en la que te abras fijado es que hay 4 variables con la misma cadena de texto. Evidentemente no es necesario que tu lo tengas de esta manera. Las cadenas de texto pueden diferir si es que es lo que ves lógico para tu caso

Sin embargo es importante que tengas claro el propósito de cada una:

lambdaFunctionId – El id de la función. Debe de ser único para cada función que vayas a crear de esta forma.

functionLocationFolderName – El nombre de la carpeta dónde esta el código de la función lambda. Recuerda que se define un folder porque puedes subir más de un archivo a tu función Lambda.

functionHandlerFolderName – El nombre o ruta de la carpeta o archivo dónde AWS va a a buscar el código a ejecutar cuando se ejecute la función. Por estándar se coloca en la raíz del proyecto, pero nada te impide colocar tu archivo de ejecución bajo tu propia estructura de carpetas.

functionHandlerFunctionName – El nombre de la función a ejecutar dentro del archivo especificado.

Creación del API REST

instantiateRestAPI()

La función encargada de crear y configurar la API.

La función recibe la lambda que creamos dado que necesita la referencia para que la podamos llamar desde la ruta que definamos.

private instantiateRestAPI = (lambdaFunction: lambda.Function) => {
    ...
}

Lo primero que hace es crear una instance de la clase RestApi. Al igual que con la función lambda necesitas pasar la referencia del stack, un id único, y un set de propiedades.

 private instantiateRestAPI = (lambdaFunction: lambda.Function) => {
    

    const restAPI = new apigateway.RestApi(this, 'sample-api', {
        restApiName: 'SampleAPI',
        description: 'Una API de muestra',
        endpointConfiguration: {
            types: [
                EndpointType.REGIONAL,
            ]
        }
        
    })

    ...

  }

Para el set de propiedades simplemente necesitarás definir el nombre de la API, una descripción y el endpoint type del API,

El endpoint type puede ser de 3 tipos:

  • Regional – Para APIs que solo serán utilizados dentro de una región geográfica.
  • Edge – Para APIs que serán distribuidos en varias regiones geográficas.
  • Private – Para APIs qué solo puedan ser accedidas desde una VPC.

El comportamiento básico es el Regional, por ende selecciona ese para este ejemplo.


La siguiente parte se encarga de llamar a otras funciones que se encargan de la configuración de la integración con la lambda, la configuración de los recursos y los métodos y la configuración de CORS.

 private instantiateRestAPI = (lambdaFunction: lambda.Function) => {
    
    ...

    const functionIntegration = this.instantiateFunctionIntegration(lambdaFunction)
  
    const resources: Resource[] = this.addResources(restAPI, functionIntegration)
    
    this.configureCORS(restAPI, resources)

    return restAPI

  }

Integración de la función Lambda

instantiateFunctionIntegration()

El siguiente código cubre la integración de la función lambda con el API.

private instantiateFunctionIntegration = (lambdaFunction: Function) => {

    const functionIntegration = new LambdaIntegration(lambdaFunction, {
      proxy: false,
      allowTestInvoke: true,
      //contentHandling: ContentHandling.CONVERT_TO_BINARY,
      integrationResponses: [
        {
          statusCode: '200',
          responseTemplates: {
            'application/json' : ''
          }
        }
      ],
    })

    return functionIntegration
}

Como puedes ver aquí es donde se usa la lambda que pasaste en los parámetros de entrada. Las propiedades que se están utilizando sirven para lo siguiente:

<strong>proxy</strong> – Si estás utilizando configuración de lambda proxy. Si no estás seguro qué es, te recomiendo ver aquí

<strong>allowTestInvoke</strong> – Si se puede hacer invocaciones de prueba o no

contentHandling – Esta propiedad esta comentada, pero la incluyo porque es importante entenderla. Tiene 3 comportamientos posibles: puede convertir la respuesta a binario, a texto plano o pasarla tal cuál llega. En nuestro caso queremos que se pase la respuesta sin modificarse (tal cual la devuelve nuestra Lambda) y si no se define contentHandling ese es el comportamiento default. Sin embargo si deseas especificar algúna otra de las opciones entonces si debes incluir la propiedad.

integrationResponses – Definen que códigos de respuesta tienen que tipo y modelo de respuesta. Aquí se puede decidir si se quiere modificar el formato de respuesta recibido antes de regresarlo al cliente. Si bien para este ejemplo no vamos a realizar modificaciones, de todas maneras necesitas configurar una respuesta para el estatus 200 por default.

Creación de los recursos y métodos

addResources()

En esta función esta toda la funcionalidad encarga de agregar los recursos y sus métodos. Aunque claro al ser este un ejemplo muy simple solo tendremos un recurso con un método.

private addResources = (restAPI: RestApi, functionIntegration: LambdaIntegration) => {
    const sampleResource: Resource = restAPI.root.addResource('sample')


    sampleResource.addMethod('GET', functionIntegration, {
      authorizationType: AuthorizationType.NONE,
      requestParameters: {
        'method.request.querystring.name': true,
        'method.request.querystring.type': true,
        'method.request.querystring.sort': false,
      },
      methodResponses: [
        {
          statusCode: '200',
          responseParameters: {},
          responseModels: {
            'application/json' : Model.fromModelName(this, 'EmptyModel', 'Empty')
          }
        }
      ]
    })

    return [
      sampleResource
    ]
}

Para crear un recurso necesitas llamar la función addResource() y pasar el nombre de tu nuevo recurso. Para agregar un recurso en la raíz de tus rutas debes llamara a restApi.root.

Para crear un método necesitas mandar a llamar la función de addMethod() en el recurso en el que quieres agregar el método. Esta función recibe el tipo de método que se va crear (en este caso un GET), el objeto de LambdaIntegration que se generó en instantiateFunctionIntegration en la función anterior y su respectivo set de propiedadades.

En las propiedades se define lo siguiente:

authorizationType – El tipo de autorización requerida para poder llamar este método. Para este ejemplo no necesitamos autorización por lo que se pone el valor de NONE.

requestParameters – Los parámetros que acepta el método. Para definir un parámetro AWS necesita que sigas el formato “method.request.querystring.<param>”. Si no sigues este formato AWS no reconocerá el parámetro. Adicionalmente debes asignarle el valor true o false dependiendo de si el parámetro debe ser obligatorio o no.

methodResponses – Aquí defines los diferentes tipos de respuestas junto con sus modelos. Si necesitas especificar modelos diferentes para una respuesta 200, 404. 400, 500, 502. etc. en esta sección puedes agregarlo.

Los modelos sirven para definir la estructura de los objetos que va a manejar el API, tanto de entrada como de salida.
Idealmente el modelo debería contener la estructura del objeto que regresa el método. Sin embargo para este ejemplo basta con usar cualquier modelo.

Dentro de methodResponses hay una propiedad llamada responseModels donde precisamente se asigna un modelo llamado Empty.

¿En dónde se creo este modelo?

AWS por default crea 2 modelos al momento de generar un API:

  • Empty
  • Error

Configuración de CORS

configureCORS()

Finalmente añade una configuración rápida para CORS. Si no sabes que es CORS te recomiendo que leas más a detalle del tema aquí.

Sin embargo si no quieres desconcentrarte por el momento digamos que es una configuración que define que sitios u origenes pueden llamar a tus métodos.

¿Porqué necesitamos esto?

La configuración por default de la web hace que cuando las peticiones vienen de un servidor diferente la petición se bloqueé, generando así un error de CORS (Cross Origin Resource Sharing).

Si no sabes del tema probablemente no te des cuenta que tu API tiene este error hasta mucho después ya que él software para realizar peticiones como Postman o Insomnia no siguen este estándar. Sin embargo los navegadores si lo hacen por lo qué muchas veces no te das cuenta hasta que alguien necesita usar la API.

private configureCORS = (restAPI: RestApi, resources: Resource[]) => {

    const accesControlAllowHeaders = 'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'
    const accessControlAllowMethods = 'GET,OPTIONS'

    const responseHeadersConfig = {
        'Access-Control-Allow-Origin': `'${Cors.ALL_ORIGINS.join(',')}'`,
        'Access-Control-Allow-Headers': `'${accesControlAllowHeaders}'`,
        'Access-Control-Allow-Methods':  `'${accessControlAllowMethods}'`
    }

    const gatewayResponse400Properties =  {
        type: ResponseType.DEFAULT_4XX,
        responseHeaders: responseHeadersConfig
    }
    const gatewayResponse500Properties =  {
        type: ResponseType.DEFAULT_5XX,
        responseHeaders: responseHeadersConfig
    }


    restAPI.addGatewayResponse('GatewayResponse4XX', gatewayResponse400Properties)
    restAPI.addGatewayResponse('GatewayResponse5XX', gatewayResponse500Properties)


     ...

 }

Como puedes ver primero se definen los valores que se van a usar para los headers asociados con CORS:

  • Access-Control-Allow-Origin
  • Access-Control-Allow-Headers
  • Access-Control-Allow-Methods

Después se definen un par de variables para respuestas en el rango de los 400 (errores del cliente) y 500 (errores del servidor) respectivamente y se agregan como respuestas al API.

Esto significa que estos headers se van a añadir a todas las respuestas del API que tengan un estatus desde el 400 hasta al 599. Dado que las respuestas de los errores por lo general no suelen diferir en una API por lo que API Gateway permite crear una configuración global para las mismas.

Es importante agregar esto ya que de no hacerse nuestro API podría comportarse bien en el caso de que la llamada al API sea éxitosa pero nos arrojaría errores de CORS en el cliente cuando haya un Bad Request, un Internal Server Error o cualquier otro error de tipo 400 o 500.


Finalmente pero no menos importante agrega la configuración CORS a nuestros recursos y métodos. Para ello estoy utilizando un arreglo de recursos que obtuve en la función addResources. (Aunque como recordarás solo estamos usando 1). Esto afectará a la respuesta con estatus 200 dado que es la única qué esta definida en el método.

private configureCORS = (restAPI: RestApi, resources: Resource[]) => {

     ...

    resources.forEach(resource => {
      resource.addCorsPreflight({
        allowOrigins: ['*'],
      })
    })

}

Configuración de deployments

configureDeployments()

Finalmente la función de configureDeployments se encarga de manejar tanto los deployments (implementaciones) y stages (etapas) del API.

Esta función define un deployment haciendo referencia al estado que tenga la API en ese momento. Los cambios subsecuentes al API no afectarán al deployment ya creado y tendrás que crear uno nuevo para ver reflejados nuevas modificaciones.

En la última parte se define un stage llamado Dev Stage. Este stage hace referencia al deployment que quieres usar y que sea accesible a través del endpoint de tu API.

*Es importante que recuerdes que cuando hagas cambios al API, estos no serán implementados en las peticiones hasta que hagas un nuevo deployment. Por lo mismo cada deployment funciona como una especie de versionamiento de tu API.

Si te interesa leer como te recomiendo manejar tus deployments para API Gateway con AWS CDK, puedes revisar como recomiendo manejar esto en un ambiente de desarrollo profesional aquí.

private configureDeployments = (restAPI: RestApi) => {
    const deployment = new apigateway.Deployment(this, 'Test Deployment', {
      api: restAPI,
      description: 'Implementacion con query params',
      retainDeployments: false
    })

    const devStage = new apigateway.Stage(this, 'Dev Stage', {
      deployment: deployment,
      stageName: 'dev'
    })
  }

Listo! Una vez ya tienes tu código listo solo necesitas correr el comando cdk deploy utilizando tu cuenta de AWS y esperar unos minutos. Recuerda que si no estas familiarizado con los comandos es mejor que primero te familiarizes con los mismos siguiendo este post.

Aquí abajo puedes encontrar el código para ambos archivos.

Código completo

cdk.ts

#!/usr/bin/env node
import 'source-map-support/register';
import * as cdk from '@aws-cdk/core';

import { RestApiLambdaIntegrationStack } from '../lib/rest-api-lambda-integration-stack';

const app = new cdk.App();

new RestApiLambdaIntegrationStack(app, 'RestApiLambdaIntegrationStack', {})

rest-api-lambda-integration-stack.ts

import { RestApiBaseComponent } from './components/rest-api-base-component'
import * as cdk from '@aws-cdk/core';
import { AuthorizationType, Resource, EndpointType, Cors, ResponseType, Deployment, Stage, Model, LambdaIntegration } from '@aws-cdk/aws-apigateway';
import { RestApi } from '@aws-cdk/aws-apigateway';
import { Code, Runtime, Function } from '@aws-cdk/aws-lambda';

export class RestApiLambdaIntegrationStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const lambdaFunction = this.instantiateLambdaFunction()
    const restAPI = this.instantiateRestAPI(lambdaFunction)

    this.configureDeployments(restAPI)
    
  }

  private instantiateLambdaFunction = () => {
    const lambdaFunctionId = 'dataLambda'
    const functionLocationFolderName = 'dataLambda'
    const functionHandlerFolderName = 'dataLambda'
    const functionHandlerFunctionName = 'dataLambda'

    const lambdaFunction = new Function(this, lambdaFunctionId, {
      functionName: functionHandlerFunctionName,
      runtime: Runtime.NODEJS_14_X,
      code: Code.fromAsset(`src/${functionLocationFolderName}`),
      
      handler: `${functionHandlerFolderName}.${functionHandlerFunctionName}`,
    })

    return lambdaFunction
  }

  private instantiateRestAPI = (lambdaFunction: Function) => {
    

    const restAPI = new RestApi(this, 'sample-api', {
        restApiName: 'SampleAPI',
        description: 'Una API de muestra',
        endpointConfiguration: {
            types: [
                EndpointType.REGIONAL,
            ]
        }
        
    })

    
    const functionIntegration = this.instantiateFunctionIntegration(lambdaFunction)
  
    const resources: Resource[]   = this.addResources(restAPI, functionIntegration)
    
    this.configureCORS(restAPI, resources)

    return restAPI
  }

  private instantiateFunctionIntegration = (lambdaFunction: Function) => {

    const functionIntegration = new LambdaIntegration(lambdaFunction, {
      proxy: false,
      allowTestInvoke: true,
      //contentHandling: ContentHandling.CONVERT_TO_BINARY,
      integrationResponses: [
        {
          statusCode: '200',
          responseTemplates: {
            'application/json' : ''
          }
        }
      ],
    })

    return functionIntegration
  }

  private addResources = (restAPI: RestApi, functionIntegration: LambdaIntegration) => {
    const sampleResource: Resource = restAPI.root.addResource('sample')


    sampleResource.addMethod('GET', functionIntegration, {
      authorizationType: AuthorizationType.NONE,
      requestParameters: {
        'method.request.querystring.name': true,
        'method.request.querystring.type': true,
        'method.request.querystring.sort': false,
      },
      methodResponses: [
        {
          statusCode: '200',
          responseParameters: {},
          responseModels: {
            'application/json' : Model.fromModelName(this, 'EmptyModel', 'Empty')
          }
        }
      ]
    })

    return [
      sampleResource
    ]
  }

  private configureCORS = (restAPI: RestApi, resources: Resource[]) => {
    const accesControlAllowHeaders = 'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'
    const accessControlAllowMethods = 'GET,OPTIONS'

    const responseHeadersConfig = {
        'Access-Control-Allow-Origin': `'${Cors.ALL_ORIGINS.join(',')}'`,
        'Access-Control-Allow-Headers': `'${accesControlAllowHeaders}'`,
        'Access-Control-Allow-Methods':  `'${accessControlAllowMethods}'`
    }

    const gatewayResponse400Properties =  {
        type: ResponseType.DEFAULT_4XX,
        responseHeaders: responseHeadersConfig
    }
    const gatewayResponse500Properties =  {
        type: ResponseType.DEFAULT_5XX,
        responseHeaders: responseHeadersConfig
    }


    restAPI.addGatewayResponse('GatewayResponse4XX', gatewayResponse400Properties)
    restAPI.addGatewayResponse('GatewayResponse5XX', gatewayResponse500Properties)

    resources.forEach(resource => {
      resource.addCorsPreflight({
        allowOrigins: ['*'],
      })
    })

  }

  private configureDeployments = (restAPI: RestApi) => {
    const deployment = new Deployment(this, 'Test Deployment', {
      api: restAPI,
      description: 'Implementacion con query params',
      retainDeployments: false
    })

    const devStage = new Stage(this, 'Dev Stage', {
      deployment: deployment,
      stageName: 'dev'
    })
  }
}